<?php

/**
 * Accepts a string, performs replacements, then returns it.
 *
 * @author    Mon Zafra <monzee@gmail.com>
 * @package   Yano
 * @copyright (c)2009 Mon Zafra
 * @license   New BSD
 */
class Yano_Template_Engine
{

/* SYNTAX:
 * ============================================================[ Var expansion ]
 * {foo}               $this->foo (valid identifiers only)
 * {$foo}              $foo (same as without braces)
 * {foo.bar}           $this->foo->bar (props only; won't work with methods)
 * {$foo.bar}          $foo->bar
 * {$foo.123.bar}      $foo->{123}->bar
 * [FOO, [BAR, BAZ]]   array(FOO, array(BAR, BAZ))
 * [FOO:BAR, (a::BAZ)] array(FOO => BAR, (a::BAZ))
 * ----------------------------------------------------------[ Basic operators ]
 * {FOO}               echo, according to _autoEscape value
 * {#FOO}              FOO as is, no variable expansion done
 * {#FOO#}             null eats the whole tag
 * {@FOO}              <?php FOO ?>
 * {:FOO = BAR}        <?php FOO = BAR; ?>
 * -------------------------------------------------------[ Nestable operators ]
 * {^FOO}              echo, reverse _autoEscape
 * {=FOO}              <?php echo FOO; ?>
 * {\FOO}              <?php echo $this->escape(FOO); ?>
 * {_FOO}              <?php echo gettext(FOO); ?>
 * -------------------------------------------------------[ Control structures ]
 * {?\FOO}             <?php if (isset(FOO)) echo $this->escape(FOO); ?>
 * {?=FOO}             <?php if (isset(FOO)) echo FOO; ?>
 * {?FOO:BAR}          <?php echo isset(FOO) ? $this->escape(FOO) : $this->escape(BAR); ?>
 * {?\FOO:=BAR}        <?php echo isset(FOO) ? $this->escape(FOO) : BAR; ?>
 * -------------------------------------------------[ Method call and chaining ]
 * {FOO|`bar|baz}      <?php echo $this->chain(FOO)->bar()->baz(); ?>
 * {FOO|`bar:BAZ}      <?php echo $this->chain(FOO)->bar(BAZ); ?>
 * {FOO|`bar:BAZ:BAT}  <?php echo $this->chain(FOO)->bar(BAZ, BAT); ?>
 * {FOO|`bar:BAZ:%}    <?php echo $this->chain(FOO)->bar(BAZ, FOO); ?>
 * {FOO|bar:BAZ:%}     <?php echo bar(BAZ, $this->chain(FOO)); ?>
 * {FOO|bar:BAZ}       <?php echo bar($this->chain(FOO), BAZ); ?>
 * {FOO|bar|`baz}      <?php echo $this->chain(bar(FOO))->baz(); ?>
 *
 * =====================================================================[ TODO ]
 * {|foo:BAR:BAZ}      <?php echo foo(BAR, BAZ); ?>
 * {|foo|bar:BAZ}      <?php echo $this->chain(foo())->bar(BAZ); ?>
 * {?=FOO:}HTML{endif} <?php if (isset(FOO)): echo FOO; else: ?>HTML<?php endif; ?>
 */
    const PRE = 1;
    const POST = 2;
    const FULL = 3;
    const DELIM_COMPLEMENT = 'complement';
    const DELIM_UNION = 'union';
    const DELIM_REPLACE = 'replace';
    protected $_autoEscape = true;
    protected $_prevAutoEscape;
    protected $_openTag= '{';
    protected $_closeTag= '}';
    protected $_internalOpenTag = '{';
    protected $_internalCloseTag = '}';
    protected $_basicOperators = array('\\', '=', '_', '^');
    protected $_chainPrefix = '$this->chain';
    protected $_escapePrefix = '$this->escape';
    protected $_translatePrefix = 'gettext';
    protected $_parser;
    protected $_parserClass = 'Yano_Template_Parser';
    protected $_processor;
    protected $_processorClass = 'Yano_Template_Processor';
    // TODO: is it possible to make these two closures in 5.3?
    protected $_preProcess;
    protected $_postProcess;

    /**
     * Set options on instantiation
     * @param array $options 
     */
    public function __construct(array $options=array())
    {
        foreach ($options as $key => $value) {
            $this->setOption($key, $value);
        }
    }

    /**
     * Compile template
     * @param string $template
     * @param int    $mode     Should pre- and/or post-processing be done?
     * @return string
     */
    public function compile($template, $mode = self::FULL)
    {
        if ($mode & self::PRE) {
            $this->_preCompile($template);
        }

        $open = $this->_openTag;
        $lenOpen = strlen($open);
        $close = $this->_closeTag;
        $lenClose = strlen($close);
        $cursor = 0;
        while (false !== $pos = strpos($template, $open, $cursor)) {
            $closePos = $this->getParser()->findCloseDelimiter($template, $pos, array($open, $close));
            $tag = substr($template, $pos, $closePos - $pos + $lenClose);
            $target = substr($tag, $lenOpen, -$lenClose);
            $op = $this->_getOperator($target, array('?', '@', '#', ':', '~'), false);
            // ignore empty tags
            if ('' == $target) {
                $cursor++;
                continue;
            }
            switch ($op) {
                case '#':
                    $replace = $this->_comment($target);
                    break;
                case '~':
                    $this->_expandVariables($target);
                    $replace = $this->_comment($target);
                    break;
                case '?':
                    $replace = '<?php ' . $this->_ifSetOr($target) . '; ?>';
                    break;
                case ':':
                    $replace = '<?php ' . $this->_assignment($target) . '; ?>';
                    break;
                case '@':
                    $prevAutoEsc = $this->_saveAutoEscape(false);
                    $res = $this->_nestOperators($target);
                    $phpClose = substr($res, -1) != ':' ? '; ?>' : ' ?>' ;
                    $replace = '<?php ' . $res . $phpClose;
                    $this->_autoEscape = $prevAutoEsc;
                    break;
                default:
                    $replace = '<?php echo ' . $this->_nestOperators($target) . '; ?>';
                    break;
            }
            $template = str_replace($tag, $replace, $template);
            $cursor = $pos + strlen($replace);
        }

        if ($mode & self::POST){
            $this->_postCompile($template);
        }
        return $template;
    }

    /**
     * Get parser object
     * @return Yano_Parser
     */
    public function getParser()
    {
        if (!$this->_parser) {
            $this->_parser = new $this->_parserClass;
        }
        return $this->_parser;
    }

    /**
     * Set main tag delimiters
     * @param array|string $tags
     * @return Yano_Compiler
     */
    public function setTags($tags)
    {
        if (!isset($tags[1])) {
            throw new Yano_Exception('Tags must be a two-element array or a two-character string.');
        }
        if ($tags[0] == $tags[1]) {
            throw new Yano_Exception('Open and close tags must be different.');
        }
        $this->_openTag = $tags[0];
        $this->_closeTag = $tags[1];
        return $this;
    }

    /**
     * Set an option only if it exists. Will silently fail otherwise.
     * @param string $key   Case sensitive! Without the leading underscore
     * @param mixed  $value
     * @return Naf_View_Compiler_Simple
     */
    public function setOption($key, $value)
    {
        $member = '_' . $key;
        if (property_exists($this, $member)) {
            $this->$member = $value;
        }
        return $this;
    }

    /**
     * Proxy to setOption()
     * @param string $key
     * @param mixed  $value 
     */
    public function __set($key, $value)
    {
        $this->setOption($key, $value);
    }

    /**
     * Capture set* calls, delegate undefined functions to the parser
     * @param string $method
     * @param array  $args
     * @return mixed
     */
    public function __call($method, $args)
    {
        if ('set' == substr($method, 0, 3)) {
            $option = strtolower($method[3]) . substr($method, 4);
            return $this->setOption($option, $args[0]);
        }
        $parser = $this->getParser();
        return call_user_func_array(array($parser, $method), $args);
    }

    /**
     * Perform preliminary processing
     * @param string $template
     */
    protected function _preCompile(&$template) {}

    /**
     * Perform post-processing
     * @param string $template
     */
    protected function _postCompile(&$template)
    {
        $replaceMap = array(
            /* <? foo ?> => <?php foo; ?> */
            '/<\?\s+(.+?);?\s*\?>/s' => '<?php $1; ?>',
            /* <?= foo ?> => <?php echo foo; ?> */
            '/<\?=\s*(.+?);?\s*\?>/s' => '<?php echo $1; ?>',
            /* merge adjacent php tags * /
             '/\?>(\s*)<\?php/s' => '$1      ', */
        );
        $template = preg_replace(array_keys($replaceMap), array_values($replaceMap), $template);
    }

    /**
     * Set autoEscape value and return the old value
     * @param bool $new
     * @return bool The previous value of autoEscape
     */
    protected function _saveAutoEscape($new)
    {
        $prev = $this->_autoEscape;
        $this->_autoEscape = $new;
        return $prev;
    }

    /**
     * Perform member and array substitution.
     * @param string $template
     */
    protected function _expandVariables(&$target)
    {
        // variables and view members
        // TODO: check if brace is unenclosed?
        preg_match_all('/{\$?[_a-z][_a-z\d\s\$\-\.]*}/i', $target, $matches);
        foreach ($matches[0] as $match) {
            $replace = substr($match, 1, -1);
            if (preg_match('/[\.\-]/', $replace)) {
                $replaceMap = array(
                    '/-(\d+|\$[_a-z][^\-\.]*)/i' => '[$1]',
                    '/-([^\-\.\[]*)/' => '[\'$1\']',
                    '/\.([^\$_a-z][^\.]*|[^\s]+[\s]+[^\s]*)/i' => '->{\'$1\'}',
                    '/\./' => '->'
                );
                $replace = preg_replace(array_keys($replaceMap), array_values($replaceMap), $replace);
            }
            if ('$' != $replace[0]) {
                $replace = '$this->' . $replace;
            }
            $target = str_replace($match, $replace, $target);
        }
        // arrays
        $cursor = 0;
        $blacklist = '/[_\w\d\]]/';
        // walk through the string until there are no more open brackets
        while (false !== $pos = strpos($target, '[', $cursor)) {
            // lazy load parser class
            $parser = !isset($parser) ? $this->getParser() : $parser;
            // do replacement if:
            // * the bracket is not within quotes
            // * character preceding the open bracket is not an alnum, underscore or close bracket
            // * there is a matching close bracket
            if ((!$parser->isEnclosed($target, $pos, array('"', "'"), self::DELIM_REPLACE)) &&
                ($pos == 0 || !preg_match($blacklist, $target[$pos-1])) &&
                (false !== ($closePos = $parser->findCloseDelimiter($target, $pos, '[]')))
            ) {
                $search = substr($target, $pos, $closePos - $pos + 1);
                // replace : with =>
                $inside = substr($search, 1, -1);
                $inside = implode(' => ', $parser->splitUnenclosed(':', $inside));
                $replace = 'array(' . $inside . ')';
                $target = str_replace($search, $replace, $target);
            }
            $cursor++;
        }
    }

    /**
     * If $var is set, echo $var. Otherwise, echo $somethingElse;
     * @param string $tag
     * @return string
     */
    protected function _ifSetOr($target)
    {
        $this->_expandVariables($target);
        if (false === $colon = strpos($target, ':')) {
            $ifClause = trim($target);
            $elseClause = '';
        } else {
            $ifClause = trim(substr($target, 0, $colon));
            $elseClause = trim(substr($target, $colon + 1));
        }
        // build return string
        $condition = $this->_getOperand($ifClause);
        if ('' === $elseClause) {
            $replace = 'if (isset(' . $condition . ')) echo ' . $this->_nestOperators($ifClause);
            // TODO: hanging else + HTML + endif
        } else {
            $replace = 'echo isset(' . $condition . ') ? ' . $this->_nestOperators($ifClause);
            $replace .= ' : ' . $this->_nestOperators($elseClause);
        }
        return $replace;
    }

    /**
     * Ignore contents of tag
     * @param string $tag
     * @return string
     */
    protected function _comment($target)
    {
        return '#' == substr($target, -1) ? '' : $target;
    }

    /**
     * Assignment operator
     * @param string $tag
     * @return string
     */
    protected function _assignment($target)
    {
        $this->_expandVariables($target);
        $parts = explode('=', $target, 2);
        $var = trim($parts[0]);
        $prevAutoEsc = $this->_saveAutoEscape(false);
        $val = $this->_nestOperators($parts[1]);
        $this->_autoEscape = $prevAutoEsc;
        return $var . ' = ' . $val;
    }

    /**
     * Substitute chained helper function calls
     * @param string $target
     * @return string
     */
    protected function _chain($target)
    {
        $parser = $this->getParser();
        $parts = $parser->splitUnenclosed('|', $target, 0, true, true);
        $prefix = $this->_chainPrefix;
        $exp = trim(array_shift($parts));
        $ret = $exp;
        foreach ($parts as $mod) {
            $modParts = $parser->splitUnenclosed(' ', $mod);
            $method = array_shift($modParts);
            $useChainer = '-' == $method[0];
            $placeholders = array_keys($modParts, '%');
            foreach ($placeholders as $position) {
                $modParts[$position] = $useChainer ? $exp : $ret;
            }
            $params = implode(', ', $modParts);
            if ($useChainer) {
                $method = substr($method, 1);
                $ret .= '->' . trim($method) . '(' . trim($params) . ')';
            } else {
                $params = empty($params) ? $ret
                : (!empty($placeholders) || '' == $ret ? $params : $ret . ', ' . $params);
                $ret = $method . '(' . $params . ')';
            }
        }
        return $ret;
    }

    /**
     * Wrap target in a function call
     * @param string $target
     * @param string $prefix
     * @return string
     */
    protected function _wrap($target, $prefix)
    {
        return rtrim($prefix, '(') . '(' . $target . ')';
    }

    /**
     * Check if the first char of the target is a valid operator
     *
     * If yes, it is returned and removed from the target string.
     * @param string $target
     * @param array  $validOps          Valid operators. Make this a string instead?
     * @param bool   $allowLeadingSpace If false, $target will be blanked out
     *                                  if the first char is a whitespace (mainly
     *                                  for exclusion of javascript blocks)
     * @return string|null
     */
    protected function _getOperator(&$target, $validOps = null, $allowLeadingSpace=true)
    {
        if (empty($validOps)) {
            $validOps = $this->_basicOperators;
        }
        if (in_array($target[0], $validOps)) {
            $op = $target[0];
            $target = trim(substr($target, 1));
        } else {
            $op = '';
            $whiteSpace = array(' ', "\n", "\t");
            if (!$allowLeadingSpace && in_array($target[0], $whiteSpace)) {
                $target = '';
            } else {
                $target = trim($target);
            }
        }
        return $op;
    }

    /**
     * Remove all operators from target
     * @param string $target
     * @param array  $validOps
     * @return string
     */
    protected function _getOperand($target, $validOps = null)
    {
        if (empty($validOps)) {
            $validOps = $this->_basicOperators;
        }
        $i = 0;
        while (isset($target[$i]) && in_array($target[$i], $validOps)) {
            $i++;
        }
        return substr($target, $i);
    }

    /**
     * Do operation/s on the operand
     * @param string $target
     * @return string
     */
    protected function _nestOperators($target)
    {
        $this->_expandVariables($target);
        $operators = array();
        while ('' !== $subop = $this->_getOperator($target)) {
            $operators[] = $subop;
        }
        // TODO: abstract suboperators
        if (false !== ($pos = strpos($target, '|')) && !$this->getParser()->isEnclosed($target, '|')) {
            $target = $this->_chain($target);
        }
        if (count($operators) == 0) {
            $operators[] = $this->_autoEscape ? '\\' : '=';
        }
        $operators = array_reverse($operators);
        foreach ($operators as $operator) {
            switch ($operator) {
                case '=':
                    $ret = $target;
                    break;
                case '\\':
                    $ret = $this->_wrap($target, $this->_escapePrefix);
                    break;
                case '_':
                    $ret = $this->_wrap($target, $this->_translatePrefix);
                    break;
                case '^':
                    $ret = $this->_autoEscape
                    ? $target : $this->_wrap($target, $this->_translatePrefix);
                    break;
                default:
                    break;
            }
            $target = $ret;
        }
        return rtrim($ret, ';');
    }

}
